{
"cells": [
{
"cell_type": "markdown",
"id": "cell-0",
"metadata": {},
"source": "# Time Systems\n\nPrecise timekeeping is fundamental to astrodynamics. A satellite in low-Earth orbit moves at ~7.5 km/s, so a 1-millisecond timing error translates to ~7.5 meters of position error. Different time scales exist because no single definition of \"time\" serves all purposes — atomic clocks, Earth rotation, and solar system dynamics each demand their own.\n\n## Why yet another time type?\n\nPython already has `datetime.datetime`, NumPy has `np.datetime64`, astropy has `astropy.time.Time`, and the standard library has `time.time()`. Adding `satkit.time` on top of all of these is a deliberate choice, not a case of not-invented-here:\n\n- **Time scale is a first-class concept.** A `datetime` has no idea whether it represents UTC, TAI, TT, or \"wall clock with unknown offset.\" Subtracting two `datetime` values across a leap second silently gives the wrong answer. `satkit.time` carries no such ambiguity — conversions between UTC, TAI, TT, TDB, UT1, and GPS are explicit, and leap seconds are applied from the bundled IERS tables rather than assumed away.\n- **Microsecond-precision integer representation.** `satkit.time` stores time internally as microseconds since the Unix epoch in a 64-bit signed integer. This gives microsecond resolution over a ±292,000-year range — the same resolution as `datetime.datetime`, but with an unambiguous epoch and monotonic integer semantics that make arithmetic, comparison, and hashing trivial and lossless. Round-trips through TAI↔UTC↔TT↔TDB↔UT1↔GPS and arithmetic with `satkit.duration` are exact to the microsecond.\n- **Leap seconds handled correctly.** `datetime` pretends leap seconds do not exist; during a real leap instant like `2016-12-31T23:59:60Z` it either refuses to parse or silently collapses it onto 23:59:59. `satkit.time` represents the leap instant faithfully and the TAI↔UTC conversion is exact on both sides of every leap.\n- **UT1 and TDB without extra dependencies.** UT1 requires IERS Earth-orientation data; TDB requires the relativistic correction from Earth's orbital motion. `satkit` already bundles IERS EOP tables and JPL ephemerides for its frame transforms and propagator, so exposing these scales as first-class types costs nothing extra and avoids forcing users to stitch together astropy + an IERS downloader + a relativity model just to log a propagation epoch.\n- **One type across the Rust core and Python bindings.** The propagator, frame transforms, SGP4 wrapper, ephemeris queries, and ground-contact search all speak `satkit.time`. Converting to `datetime` at every API boundary would be both slower and lossy.\n\nWhere none of that matters, `satkit.time` interoperates directly with `datetime.datetime` — you can construct one from the other and back, and every public API that takes a time will also accept a plain `datetime`.\n\n## Supported time scales\n\n`satkit` supports seamless conversion between six time scales:\n\n| Scale | Description | Use |\n|-------|-------------|-----|\n| **UTC** | Coordinated Universal Time | Civil time, input/output default |\n| **TAI** | International Atomic Time | Monotonic, no leap seconds |\n| **TT** | Terrestrial Time | Ephemeris calculations on Earth |\n| **TDB** | Barycentric Dynamical Time | Solar system ephemerides (JPL DE440) |\n| **UT1** | Universal Time 1 | Tied to Earth rotation angle |\n| **GPS** | GPS Time | GPS epoch (Jan 6, 1980), no leap seconds |"
},
{
"cell_type": "markdown",
"id": "cell-1",
"metadata": {},
"source": [
"## Creating Times\n",
"\n",
"`satkit.time` objects can be created from calendar dates, Julian dates, Unix timestamps, ISO strings, GPS week/second, or Python `datetime` objects. All interpret input as UTC by default."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-2",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"from datetime import datetime\n",
"\n",
"# From calendar date\n",
"t1 = sk.time(2024, 6, 15, 12, 30, 0)\n",
"print(\"Calendar: \", t1)\n",
"\n",
"# From RFC 3339 / ISO 8601 string\n",
"t2 = sk.time.from_rfc3339(\"2024-06-15T12:30:00Z\")\n",
"print(\"RFC 3339: \", t2)\n",
"\n",
"# From Julian Date\n",
"t3 = sk.time.from_jd(2460477.0208333335, sk.timescale.UTC)\n",
"print(\"Julian Date: \", t3)\n",
"\n",
"# From Modified Julian Date\n",
"t4 = sk.time.from_mjd(60476.520833333336, sk.timescale.UTC)\n",
"print(\"MJD: \", t4)\n",
"\n",
"# From Unix timestamp\n",
"t5 = sk.time.from_unixtime(1718454600.0)\n",
"print(\"Unix: \", t5)\n",
"\n",
"# From Python datetime\n",
"t6 = sk.time.from_datetime(datetime(2024, 6, 15, 12, 30, 0))\n",
"print(\"datetime: \", t6)\n",
"\n",
"# Current time\n",
"t7 = sk.time.now()\n",
"print(\"Now: \", t7)"
]
},
{
"cell_type": "markdown",
"id": "cell-3",
"metadata": {},
"source": [
"## Time Scale Conversions\n",
"\n",
"The relationships between time scales are:\n",
"\n",
"- **TAI = UTC + leap seconds** (37s as of 2017)\n",
"- **TT = TAI + 32.184s** (fixed offset, by definition)\n",
"- **GPS = TAI - 19s** (fixed offset; GPS epoch predates some leap seconds)\n",
"- **TDB ≈ TT** + periodic terms (±1.7 ms, due to Earth's orbital eccentricity)\n",
"- **UT1 = UTC + (UT1-UTC)** where (UT1-UTC) is measured and tabulated by IERS, always < 0.9s\n",
"\n",
"Use `as_jd(scale)` or `as_mjd(scale)` to convert to any scale:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-4",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"\n",
"t = sk.time(2024, 6, 15, 12, 0, 0)\n",
"\n",
"print(f\"UTC: MJD = {t.as_mjd(sk.timescale.UTC):.10f}\")\n",
"print(f\"TAI: MJD = {t.as_mjd(sk.timescale.TAI):.10f}\")\n",
"print(f\"TT: MJD = {t.as_mjd(sk.timescale.TT):.10f}\")\n",
"print(f\"TDB: MJD = {t.as_mjd(sk.timescale.TDB):.10f}\")\n",
"print(f\"UT1: MJD = {t.as_mjd(sk.timescale.UT1):.10f}\")\n",
"print(f\"GPS: MJD = {t.as_mjd(sk.timescale.GPS):.10f}\")\n",
"\n",
"# The offsets become clear when expressed in seconds\n",
"utc_mjd = t.as_mjd(sk.timescale.UTC)\n",
"print(\"\\nOffsets from UTC (seconds):\")\n",
"print(f\" TAI - UTC = {(t.as_mjd(sk.timescale.TAI) - utc_mjd) * 86400:.3f} s (leap seconds)\")\n",
"print(f\" TT - UTC = {(t.as_mjd(sk.timescale.TT) - utc_mjd) * 86400:.3f} s (TAI + 32.184s)\")\n",
"print(f\" GPS - UTC = {(t.as_mjd(sk.timescale.GPS) - utc_mjd) * 86400:.3f} s (TAI - 19s)\")\n",
"print(f\" UT1 - UTC = {(t.as_mjd(sk.timescale.UT1) - utc_mjd) * 86400:.6f} s (Earth rotation)\")\n",
"print(f\" TDB - UTC = {(t.as_mjd(sk.timescale.TDB) - utc_mjd) * 86400:.6f} s (≈TT + periodic)\")"
]
},
{
"cell_type": "markdown",
"id": "cell-5",
"metadata": {},
"source": [
"## Constructing Times in Non-UTC Scales\n",
"\n",
"To create a time from a non-UTC scale, use `from_mjd` or `from_jd` with the appropriate `timescale`:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-6",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"\n",
"# Create a time specified in TAI\n",
"# TAI is ahead of UTC by 37 leap seconds (as of 2017)\n",
"# So TAI 00:00:37 is the same instant as UTC 00:00:00\n",
"t_utc = sk.time(2024, 1, 1, 0, 0, 0)\n",
"mjd_tai = t_utc.as_mjd(sk.timescale.TAI)\n",
"t_from_tai = sk.time.from_mjd(mjd_tai, sk.timescale.TAI)\n",
"print(f\"UTC midnight: {t_utc}\")\n",
"print(f\"From TAI MJD: {t_from_tai}\")\n",
"print(f\"Same instant? {abs((t_utc - t_from_tai).seconds) < 1e-6}\")\n",
"\n",
"# GPS epoch: January 6, 1980 00:00:00 GPS\n",
"gps_epoch = sk.time.from_gps_week_and_second(0, 0)\n",
"print(f\"\\nGPS epoch (UTC): {gps_epoch}\")\n",
"\n",
"# Convert a known time to GPS MJD and back\n",
"t = sk.time(2024, 6, 15, 12, 0, 0)\n",
"mjd_gps = t.as_mjd(sk.timescale.GPS)\n",
"t_back = sk.time.from_mjd(mjd_gps, sk.timescale.GPS)\n",
"print(f\"\\nOriginal: {t}\")\n",
"print(f\"Via GPS MJD round-trip: {t_back}\")\n",
"print(f\"Match? {abs((t - t_back).seconds) < 1e-6}\")"
]
},
{
"cell_type": "markdown",
"id": "cell-7",
"metadata": {},
"source": [
"## Duration Arithmetic\n",
"\n",
"`satkit.duration` represents time intervals with microsecond precision. Durations can be added to or subtracted from time objects, and two times can be subtracted to get a duration."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-8",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"\n",
"t = sk.time(2024, 3, 20, 12, 0, 0)\n",
"\n",
"# Create durations with mixed units\n",
"d = sk.duration(days=1, hours=6, minutes=30)\n",
"print(f\"Duration: {d.days:.4f} days = {d.hours:.2f} hours = {d.seconds:.1f} seconds\")\n",
"\n",
"# Time arithmetic\n",
"t2 = t + d\n",
"print(f\"\\n{t} + {d.hours:.1f}h = {t2}\")\n",
"\n",
"# Difference between two times\n",
"new_year = sk.time(2025, 1, 1)\n",
"countdown = new_year - t\n",
"print(f\"\\nDays from {t} to New Year 2025: {countdown.days:.1f}\")\n",
"\n",
"# Generate a time array (common pattern for propagation)\n",
"times = [t + sk.duration(minutes=i * 10) for i in range(7)]\n",
"for ti in times:\n",
" print(f\" {ti}\")"
]
},
{
"cell_type": "markdown",
"id": "cell-9",
"metadata": {},
"source": [
"## Why Time Scales Matter\n",
"\n",
"Using the wrong time scale introduces systematic errors. This example shows the position error when propagating a GPS satellite for 24 hours using UTC vs. GPS time for the internal calculations. The difference between time scales at epoch translates directly into along-track position error."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-10",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"import scienceplots # noqa: F401\n",
"plt.style.use([\"science\", \"no-latex\", \"../satkit.mplstyle\"])\n",
"%config InlineBackend.figure_formats = ['svg']\n",
"\n",
"# Show the difference between time scales over a year\n",
"start = sk.time(2000, 1, 1)\n",
"times = [start + sk.duration(days=d) for d in np.linspace(0, 365*24, 2000)]\n",
"\n",
"# Compute offsets from UTC in seconds\n",
"tai_offset = [(t.as_mjd(sk.timescale.TAI) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]\n",
"tt_offset = [(t.as_mjd(sk.timescale.TT) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]\n",
"gps_offset = [(t.as_mjd(sk.timescale.GPS) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]\n",
"ut1_offset = [(t.as_mjd(sk.timescale.UT1) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]\n",
"tdb_tt = [(t.as_mjd(sk.timescale.TDB) - t.as_mjd(sk.timescale.TT)) * 86400 for t in times]\n",
"\n",
"dates = [t.as_datetime() for t in times]\n",
"\n",
"fig, axes = plt.subplots(2, 1, figsize=(10, 7))\n",
"\n",
"ax = axes[0]\n",
"ax.plot(dates, tai_offset, label=\"TAI $-$ UTC\")\n",
"ax.plot(dates, tt_offset, label=\"TT $-$ UTC\")\n",
"ax.plot(dates, gps_offset, label=\"GPS $-$ UTC\")\n",
"ax.set_ylabel(\"Offset from UTC [s]\")\n",
"ax.set_title(\"Time Scale Offsets from UTC (2000--2024)\")\n",
"ax.legend()\n",
"\n",
"ax = axes[1]\n",
"ax.plot(dates, [u * 1000 for u in ut1_offset], label=\"UT1 $-$ UTC\", color=\"C3\")\n",
"ax.set_ylabel(\"Offset [ms]\")\n",
"ax.set_xlabel(\"Year\")\n",
"ax.set_title(\"UT1 $-$ UTC (Earth Rotation Irregularities)\")\n",
"ax.legend()\n",
"\n",
"for ax in axes:\n",
" ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter(\"%Y\"))\n",
"\n",
"fig.autofmt_xdate()\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "cell-11",
"metadata": {},
"source": [
"The top panel shows the fixed offsets: TAI leads UTC by the cumulative leap second count (which jumps by 1s each time a leap second is inserted), TT is TAI + 32.184s, and GPS is TAI - 19s.\n",
"\n",
"The bottom panel shows UT1-UTC, which reflects irregularities in Earth's rotation rate. This is measured by VLBI and published by the IERS; it is kept within ±0.9s of UTC by inserting leap seconds."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-12",
"metadata": {},
"outputs": [],
"source": [
"# TDB - TT: the periodic relativistic correction\n",
"# This oscillation (~1.7 ms amplitude) is caused by Earth's orbital eccentricity:\n",
"# clocks on Earth run slightly faster at aphelion (further from Sun, weaker gravity)\n",
"# and slower at perihelion (closer to Sun, stronger gravity)\n",
"\n",
"fig, ax = plt.subplots(figsize=(10, 4))\n",
"ax.plot(dates, [t * 1000 for t in tdb_tt], \"k-\", linewidth=0.8)\n",
"ax.set_xlabel(\"Year\")\n",
"ax.set_ylabel(\"TDB $-$ TT [ms]\")\n",
"ax.set_title(\"TDB $-$ TT: Relativistic Correction from Earth's Orbital Eccentricity\")\n",
"ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter(\"%Y\"))\n",
"fig.autofmt_xdate()\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "185px8jwaxz",
"metadata": {},
"source": [
"## GPS Time\n",
"\n",
"GPS time is a continuous time scale that started aligned with UTC on January 6, 1980. Unlike UTC, GPS time does not insert leap seconds, so it has gradually drifted ahead of UTC (by 18 seconds as of 2017). The `from_gps_week_and_second` function creates a time from the GPS week number and second-of-week — the native time format used by GPS receivers."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2ns6e9rvbrt",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"\n",
"# GPS epoch: week 0, second 0 = January 6, 1980 00:00:00 UTC\n",
"gps_epoch = sk.time.from_gps_week_and_second(0, 0)\n",
"print(f\"GPS epoch: {gps_epoch}\")\n",
"\n",
"# A GPS receiver reports week 2321, second-of-week 302400\n",
"# (Wednesday at noon, since 302400 = 3.5 days * 86400 s/day)\n",
"t = sk.time.from_gps_week_and_second(2321, 302400)\n",
"print(f\"Week 2321, SOW 302400: {t}\")\n",
"\n",
"# GPS-UTC offset: GPS time is ahead of UTC by (TAI-UTC) - 19 seconds\n",
"# At GPS epoch, TAI-UTC was 19s, so GPS = UTC. Since then, 18 more\n",
"# leap seconds have been inserted, so GPS is now 18s ahead of UTC.\n",
"t_modern = sk.time(2024, 6, 15, 12, 0, 0)\n",
"gps_mjd = t_modern.as_mjd(sk.timescale.GPS)\n",
"utc_mjd = t_modern.as_mjd(sk.timescale.UTC)\n",
"print(f\"\\nGPS - UTC offset in 2024: {(gps_mjd - utc_mjd) * 86400:.0f} seconds\")\n",
"\n",
"# Round-trip: time -> GPS week/second -> time\n",
"# Compute GPS week and second-of-week from a known time\n",
"gps_days = gps_mjd - gps_epoch.as_mjd(sk.timescale.GPS)\n",
"week = int(gps_days // 7)\n",
"sow = (gps_days - week * 7) * 86400\n",
"print(f\"\\n{t_modern} in GPS: week {week}, SOW {sow:.1f}\")\n",
"t_back = sk.time.from_gps_week_and_second(week, sow)\n",
"print(f\"Round-trip: {t_back}\")\n",
"print(f\"Match: {abs((t_modern - t_back).seconds) < 1e-3}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}